查看原文
其他

这交互炸了系列 第十三式之移花接木

陈小缘 鸿洋 2019-05-29

本文作者


作者:陈小缘

链接:

https://blog.csdn.net/u011387817/article/details/89142467

本文由作者授权发布。


小缘对于自定义 ViewGroup 的理解真的非常深刻,每次的文章总能给我带来惊喜,我经常看到炫酷的动效都会发给他一下...PS这小伙很会做饭。


上篇:


这交互炸了系列 第十二式之年年有鱼


文章非常长,超过了微信 1/3的限制,全文会有删减,建议大家收藏一波,估计路上是看不完了,但是非常值得学习!


1前言


上个星期更新了网易云音乐之后,在发现->歌单页面中看到一个挺炫酷的效果,介系我没有见过的船新版本,看图:



对,一眼看上去就像是在ViewPager的基础上改造过,但仔细看,又不太像ViewPager的行为,因为它固定只有三个子View(我特意观察了几天),而且,在滑动的时候,除了尺寸和透明度的渐变,跟ViewPager有一个明显的区别就是,最前面的子View会向相反方向移动。


这就像六一儿童节孩子们排队领糖果一样:最前面的领到了糖果,还想再领一次,于是就到最后面重新排队。哈哈


还有一个比较细节的效果就是,在手指滑动到屏幕宽度的一半左右,本来在中间的子View跟即将来到中间的子View他们会交换层级顺序,看:



emmmm,这样的话,基本不用考虑改造ViewPager了,直接自定义ViewGroup吧,而且ViewPager使用起来还要定义Adapter,很繁琐。


2初步分析


先来观察一下它的行为:


1. 静止的时候,中间大,两边小并且半透明,最左边的子View看上去是在总宽度的1/4上,也就是这三个子View把屏幕宽度分成了4份;


2.滑动时,在最前面的子View会向相反方向移动,但它的透明度和尺寸都不变;中间的子View,在移动过程中会越来越透明,尺寸也会越来越小;后面的子View刚好跟中间的相反,它会变得越来越大而且不透明度也越来越大;


3. 在手指移动了大概屏幕宽度的一半时,后面的两个子View会交换层级顺序;


4. 手指松开后,会根据当前滑动的距离自行调整位置,即像ViewPager那样;


5. 静止的时候,点击两边的子View并不会直接响应它的onClick事件,而是把它移动到中间,即一个选中的效果;


再根据它的行为来捋一下大致思路:


1. 既然说把屏幕宽度分成了4份,那就是三条线,我们刚好可以根据这三条线来作为基准线,用来辅助子View定位;


2. 可以用一个变量来记录当前手指滑动距离相对于屏幕宽度的百分比,有了这个百分比,要计算出来这些alpha,scale之类的就轻而易举了;


3. 这个我们在下面讨论;


4. 这个没难度,ACTION_UP之后播放一个ValueAnimator来更新位置就行了;


5. 后面讨论;


3交换子View层级顺序


那这个交换子View层级顺序的效果,这个应该怎么做呢?


很多同学第一时间可能会想到:先remove,再add回去。


嗯,这样做虽然也能实现交换顺序,但是还是重量级了点,在一些低端机上面还可能会出现闪一下的效果(因为移除了之后不能及时地add回去)。我们还有更高效和更轻量的方法:


了解过RecyclerView回收机制的同学,应该对LayoutManager中的detachView和attachView方法很有印象:


在进行滚动的时候,LayoutManager会不断地detach无效的item,重新绑定数据之后,又会立即attach回去,那么,我们做交换层级顺序的,也可以用这种方式,来试试。


在ViewGroup中,这两个方法分别对应detachViewFromParent和attachViewToParent,但都是用protected修饰的,我们要在外面调用它就要创建一个类继承现有的ViewGroup然后重写这两个方法并把protected改为public:


@Override
    public void detachViewFromParent(View child) {
        super.detachViewFromParent(child);
    }

    @Override
    public void attachViewToParent(View child, int index, ViewGroup.LayoutParams params) {
        super.attachViewToParent(child, index, params);
    }


接着我们在Activity中监听子View点击事件:哪个子View被点击,就置顶哪个:


public void moveToTop(View target) {
    //先确定现在在哪个位置
    int startIndex = mViewGroup.indexOfChild(target);
    //计算一共需要几次交换,就可到达最上面
    int count = mViewGroup.getChildCount() - 1 - startIndex;
    for (int i = 0; i < count; i++) {
        //更新索引
        int fromIndex = mViewGroup.indexOfChild(target);
        //目标是它的上层
        int toIndex = fromIndex + 1;
        //获取需要交换位置的两个子View
        View from = target;
        View to = mViewGroup.getChildAt(toIndex);

        //先把它们拿出来
        mViewGroup.detachViewFromParent(toIndex);
        mViewGroup.detachViewFromParent(fromIndex);

        //再放回去,但是放回去的位置(索引)互换了
        mViewGroup.attachViewToParent(to, fromIndex, to.getLayoutParams());
        mViewGroup.attachViewToParent(from, toIndex, from.getLayoutParams());
    }
    //刷新
    mViewGroup.invalidate();
}


好,来看看效果:



哈哈,可以了。


4拦截子View点击事件


上面我们观察到,当那个ViewGroup静止的时候,点击两边的子View并不会直接响应它的onClick事件,而是把它移动到中间。


网易云的做这个就舒服些,因为它可以在子View接收到onClick事件的时候,先跟这个ViewGroup互动一下,但因为我们做的是一个库,不可能叫大家自己去监听onClick后,还通知一下ViewGroup的,所以我们要在内部处理好这个逻辑,也就是要拦截子View的点击事件了。


到这里有同学可能会问:拦截子View点击事件?直接重写onInterceptTouchEvent方法,在里面判断并return true不就行了?


没错,大致流程是这样,但现在的问题是:


如何找到这个被点击的View,来进行选择性的拦截?


这时候同学就会说:判断是否在子View[left, top, right, bottom]内不就行了。


emmmm,这个方法在一般情况下是可以,但是,如果子View应用过scale,translation,rotation之类的变换,就有问题了,再加上我们等下处理滑动的时候,还要将子View应用scale变换呢。


同学又会说:这种效果,在绝大多数情况下,子View都不会单独应用那些变换的,那我们也不用scale,要缩放就直接layout成子View的目标尺寸不就行了?


这样的话,直接判断是否在边界内就行啦


不,直接layout成缩放后的大小是不行的,因为如果有子View在xml里面就已经写死了长宽,那么它在测量完之后,getMeasuredWidth和getMeasuredHeight方法通常会返回这个写死的值(这里为什么是用通常,而不用总是呢? 


因为这个数值完全取决于那个子View,有的自定义View可能根本不会理会这个设置的值),这样一来,我们就控制不了它实际的尺寸了(除非是用wrap_content或match_parent),这种情况,在进行布局时,如果不按照它实际大小去layout,那么就会出现好像子View被裁剪了一样(如果layout的尺寸比实际的尺寸小的话)


所以我认为网易云的效果,它子View是定制过的,也就是说那几个子View会根据最终layout出来的尺寸去调整View的内容。但是如果作为一个库的话,不可能让大家都像网易云这样做的,所以我们就选择用scale的方式来做了。


好了,来想想应该怎么拦截吧:


本来的思路是通过反射拿到onClickListener,然后在这个listener上面再套一层我们自己的listener的,结果看源码的时候发现:



View.ListenerInfo(全部监听器都是由这个静态内部类来保管)里面的mOnClickListener是标记了@hide的!!!,又因为9.0系统禁止调用Hidden API的缘故(这里暂不讨论要怎么调用Hidden API),所以只能绕路走了,我们想一下其他方法。


还记不记得我们上次分析过的ViewGroup如何正确处理旋转、缩放、平移后的View的触摸事件:


“为什么属性动画移动后仍可点击”,你怎么答?


我们这次也刚好可以用的上,因为子View在移动过程中会进行scale操作,像刚刚那位同学说的直接判断是否在子View[left, top, right, bottom]内是不行的,正确的做法应该要像ViewGroup那样:


先检查一下这个子View所对应的矩阵有没有应用过变换,如果有的话,还要先把触摸坐标映射到矩阵变换之前的对应位置,再来判断是否在View内。


那我们现在就来模拟一下,如何判断手指当前在哪个子View上:


先看ViewGroup的源码:


public void transformPointToViewLocal(float[] point, View child) {
    ...
    if (!child.hasIdentityMatrix()) {
        child.getInverseMatrix().mapPoints(point);
    }
}


它先是调用子View的hasIdentityMatrix方法来判断是否应用过变换,如果有的话,会接着调用getInverseMatrix方法。。。。


咦??等等,为什么我们平时写代码的时候,AS没有出现过这几个方法的提示呢?点进去看看先:


final boolean hasIdentityMatrix() {
        return mRenderNode.hasIdentityMatrix();
    }


看。。看看getInverseMatrix和pointInView方法:


/**
* @hide
 */

public final Matrix getInverseMatrix() {
    ensureTransformationInfo();
    if (mTransformationInfo.mInverseMatrix == null) {
        mTransformationInfo.mInverseMatrix = new Matrix();
    }
    final Matrix matrix = mTransformationInfo.mInverseMatrix;
    mRenderNode.getInverseMatrix(matrix);
    return matrix;
}

/**
 * @hide
 */

public boolean pointInView(float localX, float localY, float slop) {
    return localX >= -slop && localY >= -slop && localX < ((mRight - mLeft) + slop) &&
            localY < ((mBottom - mTop) + slop);
}


@hide @hide @hide!


没事!来仔细看一下它们各自方法内的实现,


  • hasIdentityMatrix(),里面是直接调用mRenderNode的hasIdentityMatrix;

  • getInverseMatrix(),核心就是mRenderNode.getInverseMatrix(matrix)这句,也就是说,他也是依赖于mRenderNode的;

  • pointInView(),就是几个简单的判断,里面[mLeft, mRight, mTop, mBottom]我们也完全可以在外面使用它们的get方法来获取,这就代表着我们可以自己在外面定义方法,来代替它这个pointInView;


那么,我们现在来看mRenderNode了,先检查下它的声明:


/**
     * RenderNode holding View properties, potentially holding a DisplayList of View content.
     * <p>
     * When non-null and valid, this is expected to contain an up-to-date copy
     * of the View content. Its DisplayList content is cleared on temporary detach and reset on
     * cleanup.
     */
    final RenderNode mRenderNode;


太好了,没有被标记@hide,接下来看看它里面的hasIdentityMatrix和getInverseMatrix方法:


public boolean hasIdentityMatrix() {
        return nHasIdentityMatrix(mNativeRenderNode);
    }

    public void getInverseMatrix(@NonNull Matrix outMatrix) {
        nGetInverseTransformMatrix(mNativeRenderNode, outMatrix.native_instance);
    }


哇,这么好!居然是public的,也就是说,我们连反射都省了。


那再看看它这个类本身是不是也是public的:


/**
 * ...
 * ...
 * @hide
 */

public class RenderNode {


啊,绝望,RenderNode这个类被标记了@hide。。。



不得不说,Google爸爸在这方面确实做得很绝考虑得很周到。


怎么办,要妥协吗?


其实,还有一个不是很装逼优雅的方法也可以正确判断到当前手指在哪个View上:


我们刚刚的思路,不是有记录每个子View的scale的吗?


我们可以用子View的当前宽高来乘对应的scale,最终得出缩放后的[left, top, right, bottom],并保存在自定义的LayoutParams中;


定义pointInView方法,在里面判断[x, y]是否在刚刚保存的边界范围内就行;


emmmm,在没有其他更好的办法的情况下,用这种方法也是挺好的,起码能解决问题。


既然有后手,为何不尝试一下?


细心的同学会发现,View里面也有个getMatrix方法,这个方法可以在外部调用,即没有被标记@hide的:


public Matrix getMatrix() {
    ensureTransformationInfo();
    final Matrix matrix = mTransformationInfo.mMatrix;
    mRenderNode.getMatrix(matrix);
    return matrix;
}


可以看到,它最终也是通过RenderNode的getMatrix方法来实现的,来看看RenderNode的实现:


public void getMatrix(@NonNull Matrix outMatrix) {
    nGetTransformMatrix(mNativeRenderNode, outMatrix.native_instance);
}


有没有发现,这个getMatrix,跟我们刚刚在上面贴出来的hasIdentityMatrix和getInverseMatrix方法一样,都是直接调用对应的native方法的


而熟悉Matrix的同学会知道,在Matrix里面也有个isIdentity方法,那么,


Matrix的isIdentity跟RenderNode的hasIdentityMatrix之间又有着什么样的联系呢?

RenderNode的getMatrix和getInverseMatrix之间又有什么不同呢?它们里面究竟做了些什么呢?

emmmm,源码会给我们答案。


在系统源码 /frameworks/base/core/jni/ 目录下,会看到一个叫android_view_RenderNode.cpp的文件


// 篇幅过长原因L这里省略了 native 分析部分,直接给出结论了。


现在我们完全可以不使用反射,来做到像ViewGroup那样,判断触摸点是否在子View范围内了。


现在来试一下:


/**
 * @param view 目标view
 * @param points 坐标点(x, y)
 * @return 坐标点是否在view范围内
 */

private boolean pointInView(View view, float[] points) {
    // 像ViewGroup那样,先对齐一下Left和Top
    points[0] -= view.getLeft();
    points[1] -= view.getTop();
    // 获取View所对应的矩阵
    Matrix matrix = view.getMatrix();
    // 如果矩阵有应用过变换
    if (!matrix.isIdentity()) {
        // 反转矩阵
        matrix.invert(matrix);
        // 映射坐标点
        matrix.mapPoints(points);
    }
    //判断坐标点是否在view范围内
    return points[0] >= 0 && points[1] >= 0 && points[0] < view.getWidth() && points[1] < view.getHeight();
}



哈哈哈,成功了!完美避开使用反射。


那么等下写代码的时候,就可以将这个方法应用到我们的ViewGroup当中,用来拦截子View原有的点击事件。



5 编写代码


好啦,那么现在我们总体的思路也有了,是时候开始写代码了。


在开始之前,先给我们的ViewGroup起个名字吧,因为它的行为比较像ViewPager,不过它的Item又不能像ViewPager那样不固定,在可扩展性这方面是不及ViewPager。


但是,ViewPager使用起来的流程比较繁琐,还要定义Adapter之类的,而我们这个就不用,所以在易用性方面,我们的ViewGroup更胜一筹。


在日常开发中,我们所使用的数据库通常都是SQLite,寓意是轻量级的数据库,那么我们的ViewGroup也可以叫LitePager,寓意是轻量级的ViewPager,挺洋气的名字,哈哈哈哈哈,就叫LitePager吧。


测量


好,开始吧,首先是onMeasure,那宽高应该怎么确定呢? 如果宽高设置了wrap_content的话:


  1. 宽度可以用它那三个子View的宽度之和;

  2. 高度就使用它的子View中高度最大的吧(大多数场景下子View高度都是统一的);


来看看代码怎么写:


override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 先测量子View
        measureChildren(widthMeasureSpec, heightMeasureSpec)

        val width = measureWidth(widthMeasureSpec)
        val height = measureHeight(heightMeasureSpec)

        setMeasuredDimension(width, height)
    }

    private fun measureHeight(heightMeasureSpec: Int)Int {
        var height = 0

        val heightSize = MeasureSpec.getSize(heightMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)

        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize
        } else {
            //如果高度设置了wrap_content,则取最大的子View高
            var maxChildHeight = 0
            for (i in 0 until childCount) {
                val child = this[i]
                val layoutParams = child.layoutParams as LayoutParams
                maxChildHeight = Math.max(maxChildHeight, child.measuredHeight
                        + layoutParams.topMargin + layoutParams.bottomMargin)
            }
            height = maxChildHeight
        }
        return height
    }

    private fun measureWidth(widthMeasureSpec: Int)Int {
        var width = 0

        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)

        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize
        } else {
            //如果宽度设置了wrap_content,则取全部子View的宽度和
            for (i in 0 until childCount) {
                val child = this[i]
                val layoutParams = child.layoutParams as LayoutParams
                width += child.measuredWidth + layoutParams.leftMargin + layoutParams.rightMargin
            }
        }
        return width
    }


虽然是Kotlin代码,但是逻辑挺清晰的,就算不熟悉Kotlin的同学也能很轻易的看懂(还没开始学Kotlin的同学赶快跟上大家的脚步啦~)。


布局


好,接下来到布局了,上面我们分析过,可以用三条基准线来辅助定位,来看看代码怎么写:


override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    for (index in 0 until childCount) {
        val child = this[index]
        //获取基准线
        val baseLine = getBaselineByChild(child)
        //布局子View
        layoutChild(child, baseLine)
    }
}

private fun getBaselineByChild(child: View) =
        //根据子View在ViewGroup中的索引计算基准线
        when (indexOfChild(child)) {
            0 -> width / 4 //左边的线 (最底的子View放在左边)
            1 -> width / 2 + width / 4 //右边的线 (接着放在右边)
            2 -> width / 2 //中间的线 (最顶的子View放在中间)
            else -> 0
        }

private fun layoutChild(child: View, baseLine: Int) {
    //获取子View测量宽高
    val childWidth = child.measuredWidth
    val childHeight = child.measuredHeight
    //垂直的中心位置,即高度的一半
    val baseLineCenterY = height / 2
    //根据基准线来定位水平上的位置
    val left = baseLine - childWidth / 2
    val right = left + childWidth
    //垂直居中
    val top = baseLineCenterY - childHeight / 2
    val bottom = top + childHeight

    val lp = child.layoutParams as LayoutParams

    child.layout(left + lp.leftMargin + paddingLeft,
            top + lp.topMargin + paddingTop,
            right + lp.leftMargin - paddingRight,
            bottom + lp.topMargin - paddingBottom)
}


可以看到,获取基准线被定义成了一个单独的方法getBaselineByChild,为什么呢,因为等下处理滑动手势的时候,这个基准线是需要动态计算的。


有同学可能会问:这个方法里面的width是从哪里来的呢?没看到有在哪里声明啊


这个也是Kotlin的特性之一,我们看到的width,其实是访问getter方法,在这里也就是getWidth()了,还有layoutChild方法里面的height也是同理,调用的是getHeight()。


好,来看看初步的效果(为了更容易理解,加上了辅助线):



emmmm,挺好。


处理缩放和透明度


上面我们看到网易云的效果是两边的子View会变小和变透明,那么这些属性(缩放比例、透明度)肯定要用变量保存起来的,保存在哪里好呢?


用一个集合来装吗?当然不是了,我们可以扩展LayoutParams,把这些属性都放在LayoutParams里面:

因为我们的ViewGroup需要支持Margin,所以继承自MarginLayoutParams:


class LayoutParams : MarginLayoutParams {
    var scale = 0F
    var alpha = 0F
    constructor(c: Context, attrs: AttributeSet) : super(c, attrs)
   constructor(width: Int, height: Int) : super(width, height)
    constructor(source: ViewGroup.LayoutParams) : super(source)
}


定义好之后,还要重写三个生成LayoutParams的方法,并在里面返回我们自己的LayoutParams:


override fun generateLayoutParams(attrs: AttributeSet) = LayoutParams(context, attrs)

override fun generateLayoutParams(p: ViewGroup.LayoutParams) = LayoutParams(p)

override fun generateDefaultLayoutParams() = LayoutParams(WRAP_CONTENT, WRAP_CONTENT)


那么,LayoutParams中的scale和alpha在哪里初始化好呢?


当然是在addView方法里了,在这里,我们还可以顺便限制一下子View的个数(因为超过三个的话,就不知道应该怎么布局了。。。网易云上也只有固定的三个):


override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams) {
    if (childCount > 2) {
        //满座了就直接拋异常,提示不能超过三个子View
        throw IllegalStateException("LitePager can only contain 3 child!")
    }
    //如果传进来的LayoutParams不是我们自定义的LayoutParams的话,就要创建一个
    val lp = if (params is LayoutParams) params else LayoutParams(params)
    if (childCount < 2) {
        lp.alpha = mMinAlpha
        lp.scale = mMinScale
    } else {
        lp.alpha = 1F
        lp.scale = 1F
    }
    super.addView(child, index, params)
}


可以看到,我们重写的addView方法,在限制子View个数之后,会进行判断:如果是最后一个,也就是第三个添加进来的子View,alpha和scale才是"正常"的(缩放比例和不透明度都是1,即100%)


mMinAlpha和mMinScale用来保存最小的不透明度和缩放比例,当然了,这两个值是可以给外部修改的,默认值分别是0.4和0.8。


接下来,还需要修改一下刚刚的layoutChild方法:在进行layout之前,先更新一下不透明度和缩放比例:


private fun layoutChild(child: View, baseLine: Int) {
    val lp = child.layoutParams as LayoutParams

    //更新不透明度
    child.alpha = lp.alpha
    //更新缩放比例
    child.scaleX = lp.scale
    child.scaleY = lp.scale

    //其他地方不变
    ...
    ...
}


这个child.xxx = xxx,其实是调用setter方法啦,child.alpha = lp.alpha 等于java中的 child.setAlpha(lp.alpha);


好了,来看看效果:



emmmm,可以看到,现在的效果已经跟网易云的差不多了,下面我们来添加手势滑动的效果。


支持滑动手势


网易云的处理方式是:子View从当前基准点移动到下一个基准点时,偏移量刚好等于ViewGroup的宽度,也就是说,当手指的水平滑动距离=ViewGroup宽度时,这个ViewGroup也刚好切换页面了(即进行了一次完整的滑动)。


这样的话,我们就需要计算手指水平滑动的百分比,然后转换成基准线之间的百分比,计算出偏移的距离后,还需要进行以下处理:


1. 如果手指是向左滑动,那么最左边的子View(index=0),要向右偏移。反之,如果是向右滑动的话,最右边的子View(index=1)要向左偏移;


2. 当滑动到ViewGroup宽度的一半时,新旧的中间View要交换层级顺序;


3. 当水平滑动的距离超出ViewGroup宽度时,应该当作是新的一次偏移了,这个在每次计算前判断一下就行了;


第一个没什么难度,先用indexOfChild方法获取到子View在ViewGroup中的索引然后根据这个索引来判断就行了。


第二个,我们在开头的时候就已经分析过要怎么交换顺序了,所以等下可以直接把那个方法应用到这里来;


第三,如果当前向右滑动的距离=ViewGroup的宽度,也就是说这时候已经进行了一次完整的滑动了:本来在右边的子View,现在已经到了左边、本来在中间的,现在在右边、本来在左边的,现在到了中间。那么,如果现在继续向右滑动的话,一开始在右边的子View(现在在左边),就要向右移动而不是上一次的向左了,其他两个子View同理。


这种情况的话,怎么去正确的判断哪个子View是要往左,哪个要往右呢?想一下


有没有发现,这种问题可以用我们生活中的场景来辅助解决:我们平时叫滴滴,你告诉师傅你在哪,要去哪里,只要你的出发地和目的地正确,师傅就能顺利的把你送到目的地。哈哈哈


那么,我们也可以在LayoutParams里面记录子View的from和to,在每一次完整的滑动之后,更新每一个子View的这两个值。


嗯,就这么决定了,开始写代码:


首先是重新onInterceptTouchEvent方法,我们要在这里去判断并拦截触摸事件:


override fun onInterceptTouchEvent(event: MotionEvent)Boolean {
    val x = event.x
    val y = event.y
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            //更新上一次的触摸坐标
            mLastX = x
            mLastY = y
        }
        MotionEvent.ACTION_MOVE -> {
            val offsetX = x - mLastX
            val offsetY = y - mLastY
            //判断是否触发拖动事件
            if (Math.abs(offsetX) > mTouchSlop || Math.abs(offsetY) > mTouchSlop) {
                //更新上一次的触摸坐标
                mLastX = x
                mLastY = y
                //标记已开始拖拽
                isBeingDragged = true
            }
        }
        MotionEvent.ACTION_UP -> {
            //标记没有在拖拽
            isBeingDragged = false
        }
    }
    return isBeingDragged
}


这里跟一般的ViewGroup没什么不同,大致逻辑就是:判断手指的移动距离是否>指定值,如果是,就拦截并标记正在拖拽。


接下来到onTouchEvent,我们在里面要处理的逻辑有:


  1. 更新滑动百分比;

  2. 更新子View的出发点和目的地;

  3. 更新子View的层级顺序;

  4. 更新子View的不透明度和缩放比例;


来看看代码:


override fun onTouchEvent(event: MotionEvent)Boolean {
    val x = event.x
    val y = event.y
    when (event.action) {
        MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
            val offsetX = x - mLastX
            mOffsetX += offsetX
            onItemMove()
        }
        MotionEvent.ACTION_UP -> {
            isBeingDragged = false
        }
    }
    mLastX = x
    mLastY = y
    return true
}


可以看到我们把处理ACTION_MOVE单独定义了一个方法,把刚刚说的要做的事情都放在onItemMove里面:

 

private fun onItemMove() {
    //更新滑动百分比
    mOffsetPercent = mOffsetX / width
    //更新子View的出发点和目的地
    updateChildrenFromAndTo()
    //更新子View的层级顺序
    updateChildrenOrder()
    //更新子View的不透明度和缩放比例
    updateChildrenAlphaAndScale()
    //请求重新布局
    requestLayout()
}


先来看一下如何更新出发点和目的地(updateChildrenFromAndTo()),逻辑稍微有点复杂,要仔细看一下注释。


private fun updateChildrenFromAndTo() {
    //如果滑动的距离>=ViewGroup宽度
    if (Math.abs(mOffsetPercent) >= 1) {
        //遍历子View,标记已经到达目的地
        for (i in 0 until childCount) {
            val lp = this[i].layoutParams as LayoutParams
            lp.from = lp.to
        }
        //处理溢出: 比如总宽度是100,现在是120,那么处理之后会变成20
        mOffsetX %= width.toFloat()
        //同理,这个是百分比
        mOffsetPercent %= 1F
    } else {
        //遍历子View,并根据当前滑动的百分比来更新子View的目的地
        for (i in 0 until childCount) {
            val lp = this[i].layoutParams as LayoutParams
            lp.to = when (lp.from) {
                //最左边的子View,如果是向右滑动的话,那么它的目的地是中间,也就是2了
                //如果是向左滑动的话,目的地是最右边的位置,也是1了,下面同理
                0 -> if (mOffsetPercent > 02 else 1
                //最右边的子View,如果是向右滑动,那么目的地就是最左边(0),反之,在中间(2)
                1 -> if (mOffsetPercent > 00 else 2
                //中间的子View,如果向右滑动,目的地是右边(1),向左就是左边(0)
                2 -> if (mOffsetPercent > 01 else 0
                else -> return
            }
        }
    }
}


可以看到有一句是lp.to = when (lp.from),还没开始学习Kotlin的同学可能会不了解


这个when,有点像java的switch,但是跟switch最大的区别就是,when是一个表达式,也就是可以有返回值,所以上面那句lp.to = when (lp.from),这个lp.to,最终接收的时候when里面的if else的返回值。


好,接下来看看updateChildrenOrder方法,要注意的是,每次滑动距离超过50%的时候只会交换一次顺序,除了这个还要处理回退的问题,也就是滑动超过一半后(这时已经交换过顺序),又反方向滑动,这时候也要交换一次顺序:


private fun updateChildrenOrder() {
    //如果滑动距离超过了ViewGroup宽度的一半,
    //就把索引为1,2的子View交换顺序,并标记已经交换过
    if (Math.abs(mOffsetPercent) > .5F) {
        if (!isReordered) {
            exchangeOrder(12)
            isReordered = true
        }
    } else {
        //滑动距离没有超过宽度一半,即有可能是滑动超过一半然后滑动回来
        //如果isReordered=true,就表示本次滑动已经交换过顺序
        //所以要再次交换一下
        if (isReordered) {
            exchangeOrder(12)
            isReordered = false
        }
    }
}


现在还不行,还要在刚刚的updateChildrenFromAndTo方法内判断滑动完成那里,重置这个isReordered,因为如果不在那里重置的话,在下一次该交换顺序的时候就会出问题了,我们来修改一下updateChildrenFromAndTo方法:


private fun updateChildrenFromAndTo() {
    if (Math.abs(mOffsetPercent) >= 1) {
        //在这里要重置一下标记
        isReordered = false
        //其他地方不变
        ...
    } else {
        //其他地方不变
        ...
    }
}


好,回到updateChildrenOrder方法中,可以看到交换顺序的exchangeOrder方法,也就是我们一开始分析的那段:


private fun exchangeOrder(fromIndex: Int, toIndex: Int) {
    //一样的就不用换了
    if (fromIndex == toIndex) {
        return
    }
    //先获取引用
    val from = this[fromIndex]
    val to = this[toIndex]

    //分离出来
    detachViewFromParent(from)
    detachViewFromParent(to)

    //重新放回去,但是index互换了
    attachViewToParent(from, if (toIndex > childCount) childCount else toIndex, from.layoutParams)
    attachViewToParent(to, if (fromIndex > childCount) childCount else fromIndex, to.layoutParams)

    //通知重绘,刷新视图
    invalidate()
}


还有最后的updateChildrenAlphaAndScale,这个逻辑也有点复杂,要仔细看注释:


private fun updateChildrenAlphaAndScale() {
    //遍历子View
    for (i in 0 until childCount) {
        updateAlphaAndScale(this[i])
    }
}

private fun updateAlphaAndScale(child: View) {
    val lp = child.layoutParams as LayoutParams
    //用出发点来作为条件,而不是当前索引,因为如果使用当前索引的话,在交换顺序之后,就不正确了
    when (lp.from) {
        //最左边的子View
        0 -> when (lp.to) {
            //如果它目的地是最右边的话
            1 -> {
                //要把它放在最底,为了避免在移动过程中遮挡其他子View
                setAsBottom(child)
                //透明度和缩放比例都不用变,因为现在就已经满足条件了
            }
            //如果它要移动到中间
            2 -> {
                //根据滑动比例来计算出当前的透明度和缩放比例
                lp.alpha = mMinAlpha + (1F - mMinAlpha) * mOffsetPercent
                lp.scale = mMinScale + (1F - mMinScale) * mOffsetPercent
            }
        }
        //最右边的子View
        1 -> when (lp.to) {
            0 -> {
                //把它放在最底,避免在移动过程中遮挡其他子View
                setAsBottom(child)
                //透明度和缩放比例都不用变
            }
            2 -> {
                //这里跟上面唯一不同的地方就是mOffsetPercent要取负的
                //因为它向中间移动的时候,mOffsetPercent是负数,这样做就刚好抵消
                lp.alpha = mMinAlpha + (1F - mMinAlpha) * -mOffsetPercent
                lp.scale = mMinScale + (1F - mMinScale) * -mOffsetPercent
            }
        }
        //中间的子View
        2 -> {
            //这里不用判断to了,因为无论向哪一边滑动,不透明度和缩放比例都是减少
            lp.alpha = 1F - (1F - mMinAlpha) * Math.abs(mOffsetPercent)
            lp.scale = 1F - (1F - mMinScale) * Math.abs(mOffsetPercent)
        }
    }
}


setAsBottom方法也就是把目标子View的索引跟索引0交换顺序:


private fun setAsBottom(child: View) {
//获取child索引后跟0交换层级顺序
exchangeOrder(indexOfChild(child), 0)
}


还记不记得我们刚刚重写onLayout方法的时候,有个获取基准线的方法(getBaselineByChild),里面返回的数值是写死的?


我们现在还要改一下它,改成根据滑动百分比(mOffsetPercent)来动态计算基准线:


private fun getBaselineByChild(child: View)Int {
    //左边View的初始基准线
    val baseLineLeft = width / 4
    //中间的
    val baseLineCenter = width / 2
    //右边的
    val baseLineRight = width - baseLineLeft

    var baseLine = 0

    val lp = child.layoutParams as LayoutParams
    //用出发点来作为条件,而不是当前索引,因为如果使用当前索引的话,在交换顺序之后,就不正确了
    when (lp.from) {
        //左边的子View
        0 -> baseLine = when (lp.to) {
            //目的地是1,证明手指正在向左滑动,所以下面的mOffsetPercent是用负的
            //当前基准线 = 初始基准线 + 与目标基准线(现在是右边)的距离 * 滑动百分比
            1 -> baseLineLeft + ((baseLineRight - baseLineLeft) * -mOffsetPercent).toInt()

            //如果目的地是中间(2),那目标基准线就是ViewGroup宽度的一半了(baseLineCenter),计算方法同上
            2 -> baseLineLeft + ((baseLineCenter - baseLineLeft) * mOffsetPercent).toInt()
            else -> baseLineLeft
        }
        //右边的子View
        1 -> baseLine = when (lp.to) {
            //原理同上
            0 -> baseLineRight + ((baseLineRight - baseLineLeft) * -mOffsetPercent).toInt()
            2 -> baseLineRight + ((baseLineRight - baseLineCenter) * mOffsetPercent).toInt()
            else -> baseLineRight
        }
        //中间的子View
        2 -> baseLine = when (lp.to) {
            //原理同上
            0 -> baseLineCenter + ((baseLineCenter - baseLineLeft) * mOffsetPercent).toInt()
            1 -> baseLineCenter + ((baseLineRight - baseLineCenter) * mOffsetPercent).toInt()
            else -> baseLineCenter
        }
    }
    return baseLine
}


好啦,来看看现在的效果是怎么样的:



哈哈哈,差不多了,接下来我们处理一下手指松开的事件:当手指松开后,要播放选中动画。


加入选中动画


终于来到简单的部分了,我们先改一下onTouchEvent和onInterceptTouchEvent方法,在ACTION_UP里面加上一个handleActionUp方法:


override fun onTouchEvent(event: MotionEvent)Boolean {
    ...
    when (event.action) {
        ...
        MotionEvent.ACTION_UP -> {
            ...
            handleActionUp(x, y)
        }
    }
    ...
}

override fun onInterceptTouchEvent(event: MotionEvent)Boolean {
    ...
    when (event.action) {
        ...
        MotionEvent.ACTION_UP -> {
            ...
            handleActionUp(x, y)
        }
    }
    ...
}

private fun handleActionUp(x: Float, y: Float) {
    playFixingAnimation()
}


可以看到handleActionUp里面先是直接调用了playFixingAnimation方法:


private fun playFixingAnimation() {
    //没有子View还播放什么动画,出去
    if (childCount == 0) {
        return
    }
    //起始点,就是当前的滑动距离
    val start = mOffsetX
    //结束点
    val end = when {
        //如果滑动的距离超过了宽度的一半,那么就顺势往那边走下去
        //如果滑动百分比是正数,表示是向右滑了>50%,所以目的地就是宽度
        mOffsetPercent > .5F -> width.toFloat()
        //相反,如果是负数,那就拿负的宽度
        mOffsetPercent < -.5F -> -width.toFloat()
        //如果滑动没超过50%,那就把距离变成0,也就是回退了
        else -> 0F
    }
    startValueAnimator(start, end)
}

private fun startValueAnimator(start: Float, end: Float) {
    if (start == end) {
        //起始点和结束点一样,那还播放什么动画,出去
        return
    }
    //先打断之前的动画,如果正在播放的话
    abortAnimation()
    //创建动画对象
    mAnimator = with(ValueAnimator.ofFloat(start, end)){
        //指定动画时长
        duration = mFlingDuration
        //监听动画更新
        addUpdateListener { animation ->
            val currentValue = animation.animatedValue as Float
            //更新滑动距离
            mOffsetX = currentValue
            //处理子View的移动行为
            onItemMove()
        }
        //开始动画
        start()
        this
    }
}

private fun abortAnimation() {
    mAnimator?.let { if (it.isRunning) it.cancel() }
}


创建动画对象那里,对于还没开始学习Kotlin的同学可能会觉得很陌生,那个with看起来好像是跟java中的switch、if、else这些关键字一样


其实不是,它也是一个方法:接收一个对象,然后返回一个对象,不难看出,我们传进去的是一个ValueAnimator对象,后面紧跟的{ }先忽略,直接看里面的内容:它一开始的赋值,和接下来的调用addUpdateListener和start方法,都是ValueAnimator的,也就是说,在Kotlin中,使用这个with方法之后,后面的lambda可以直接访问with参数里面的属性和方法,而不用指定对象(xxx.),最后的this,就是返回传进去的这个对象。


那个abortAnimation方法,其实等于java的:


private void abortAnimation() {
    if (mAnimator != null && mAnimator.isRunning()) {
        mAnimator.cancel();
    }
}


好啦,来看看效果:



可以可以。


最后还有一个小节 处理点击事件 因为篇幅原因被省略了...相对来说不影响全文阅读,感兴趣请移步原文。


哈哈哈,可以啦~


发下最终的效果图:



那个切换方向,还有指定最大缩放比例、最大不透明度就当留给同学们的作业啦,在Github上有源码。


好了,本篇文章到此结束,有错误的地方请指出,谢谢大家!


Github地址:

https://github.com/wuyr/LitePager 

欢迎Star



推荐阅读

各大互联网公司技术分享

Android 进阶探索  为什么别人成长那么快?



扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存